/* NavigationModel.java

	Purpose:

	Description:

	History:
		Wed Aug 15 16:46:37 CST 2018, Created by rudyhuang

Copyright (C) 2018 Potix Corporation. All Rights Reserved.
*/
package org.zkoss.zuti.zul;

import java.io.Serializable;
import java.util.ArrayList;
import java.util.List;

import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

import org.zkoss.io.Serializables;
import org.zkoss.lang.Classes;
import org.zkoss.lang.Strings;
import org.zkoss.zk.ui.Executions;
import org.zkoss.zk.ui.event.EventListener;
import org.zkoss.zuti.event.NavigationEvent;

/**
 * A model that accepts arbitrary levels and arbitrary items for navigation.
 * Use with {@link Apply} component.
 *
 * A typical usage is that you can get each {@link NavigationLevel} by {@link NavigationLevel#getChild()} recursively.
 *
 * <h3>Usage</h3>
 * Create a new {@link NavigationModel}. Add some data by calling {@code put} method.
 * The data is associated with a path. Data is stored like a tree hierarchy.
 * <pre><code>
 *     NavigationModel&lt;String> navModel = new NavigationModel&lt;String>();
 *     navModel.put("Step 1", "step1.zul");
 *     navModel.put("Step 1/Step 1-1", "step1-1.zul");
 *     navModel.put("Step 2", "step2.zul");
 *     navModel.put("Step 2/Step 2-1", "step2-1.zul");
 *     navModel.put("Step 2/Step 2-2", "step2-2.zul");
 *     navModel.put("Step 2/Step 2-2/Step 2-2-1", "step2-2-1.zul");
 *     navModel.put("Step 2/Step 2-2/Step 2-2-2", "step2-2-2.zul");
 *     navModel.put("Step 3", "step3.zul");
 * </code></pre>
 *
 * By using {@code <apply>} tags, we can include and navigate this zul files in a single page.
 * Calling {@code navigateTo} can navigate to the path.
 * The {@code context} is a {@code Map} to put something useful.
 * <pre><code>
 *     &lt;apply level1="@load(vm.navModel.root)"
 *            templateURI="@load(level1.current)"
 *            context="@load(level1.context)"/&gt;
 * </code></pre>
 *
 * By using the same approach, get child level navigation recursively in these included zul files.
 * <pre><code>
 *     &lt;apply level2="@load(level1.child)"
 *            templateURI="@load(level2.current)"
 *            context="@load(level2.context)"/&gt;
 * </code></pre>
 *
 * Add a link or button to navigate what you want in the same level.
 * For instance, {@code level1} can be used to navigate through Step 1 to Step 3.
 * <pre><code>
 *     &lt;a label="Step 2"
 *           onClick='level1.setContext(Collections.singletonMap("hello", "world")).navigateTo("Step 2")' /&gt;
 * </code></pre>
 *
 * <h3>Path</h3>
 * A path represents the location of an item. For example, {@code Section B/Sub Section C/My Item}.
 * By default it is separated by {@code /} symbol and recognized as keys in each level.
 *
 * Sometimes we may want to use {@code /} in the key name. Using a path string could get the unexpected result.
 * There are another methods that accept {@code String[]} instead of path string to avoid this issue.
 *
 * <h3>Events</h3>
 * If the data in the model is changed or the current navigation is changed,
 * the listeners registered by {@link #addEventListener(EventListener)} will be invoked.
 *
 * The event object is {@link NavigationEvent}.
 *
 * @param <T> The data type
 * @author rudyhuang
 * @see Apply
 * @since 8.6.0
 */
public class NavigationModel<T> implements Serializable {
	private static final Logger log = LoggerFactory.getLogger(NavigationModel.class);
	private static boolean HAS_ZKBIND = true;

	private NavigationNode<T> _root = new NavigationNode<T>();
	private DefaultNavigationLevel<T> _firstLevel = new DefaultNavigationLevel<T>(this, 1);
	private transient List<EventListener<NavigationEvent<T>>> _listeners
			= new ArrayList<EventListener<NavigationEvent<T>>>();

	static {
		try {
			Classes.forNameByThread("org.zkoss.bind.BindUtils");
		} catch (ClassNotFoundException e) {
			HAS_ZKBIND = false;
		}
	}

	private static void checkPath(String path) {
		if (Strings.isEmpty(path))
			throw new IllegalArgumentException("path cannot be null or empty");
	}

	private static void checkLevels(String[] levels) {
		if (levels == null || levels.length == 0)
			throw new IllegalArgumentException("levels cannot be null or empty");
	}

	private static void checkKey(String key) {
		if (Strings.isEmpty(key))
			throw new IllegalArgumentException("key cannot be null or empty");
	}

	/**
	 * Puts the data to the path.
	 * It will replace the data of the path if the previous data associated with {@code path} exists.
	 *
	 * If any {@code /} symbol is included in the path string,
	 * it is suggested to call {@link #put(String[], Object)} instead.
	 *
	 * @param path a path string. Will be separated into levels by {@code /} symbols. Like {@code A/B/C}
	 * @param data a data
	 * @return the previous data associated with {@code path}, or {@code null} if there was no mapping.
	 */
	public T put(String path, T data) {
		checkPath(path);
		return put(path.split("/"), data);
	}

	/**
	 * Puts the data to the path.
	 * It will replace the data of the path if the previous data associated with {@code levels} exists.
	 *
	 * @param levels an array that contains each level keys as a path
	 * @param data a data
	 * @return the previous data associated with {@code levels}, or {@code null} if there was no mapping.
	 */
	public T put(String[] levels, T data) {
		checkLevels(levels);

		NavigationNode<T> base = _root;
		for (int i = 0, len = levels.length; i < len; i++) {
			String key = levels[i];
			checkKey(key);
			T value = (i + 1 == len) ? data : null;

			if (base.getChild() == null) {
				NavigationNode<T> node = new NavigationNode<T>(key, value);
				base.setChild(node);
				node.setParent(base);
				base = node;
			} else {
				base = base.getChild();
				boolean isDone = false;
				while (true) {
					if (base.getKey().equals(key)) {
						if (i + 1 == len) {
							T oldVal = base.getValue();
							base.setValue(value);
							notifyChange(len, NavigationEvent.Type.CHANGE_DATA, base);
							return oldVal;
						}
						isDone = true;
						break;
					}
					if (base.getRight() == null) break;
					base = base.getRight();
				}
				if (isDone) continue;

				NavigationNode<T> node = new NavigationNode<T>(key, value);
				base.setRight(node);
				node.setLeft(base);
				base = node;
			}
		}
		notifyChange(levels.length, NavigationEvent.Type.ADD, base);
		return null;
	}

	private void notifyChange(int level, NavigationEvent.Type type, NavigationNode<T> newNode) {
		NavigationLevel<T> navLevel = getNavigationLevel(level);
		if (navLevel != null)
			notify(type, navLevel, newNode, "itemIterator", "items");
	}

	private void notify(NavigationEvent.Type type,
	                    NavigationLevel<T> level,
	                    NavigationNode<T> node,
	                    String... props) {
		if (Executions.getCurrent() == null)
			return;
		fireEvent(level, type, node);
		if (HAS_ZKBIND)
			org.zkoss.bind.BindUtils.postNotifyChange(null, null, level, props);
	}

	/**
	 * Appends the data after the specific path.
	 * If the path is invalid, the appending will be failed.
	 * If the key is duplicated in the same level, the appending will be failed, too.
	 *
	 * @param path a path string. Will be separated into levels by {@code /} symbols. Like {@code A/B/C}
	 * @param key an item key. Must be unique in the same level
	 * @param data a data
	 * @throws IllegalArgumentException if the path is invalid, the key is invalid or duplicated in the same level
	 */
	public void append(String path, String key, T data) {
		checkPath(path);
		append(path.split("/"), key, data);
	}

	/**
	 * Appends the data after the specific path.
	 * If the path is invalid, the appending will be failed.
	 * If the key is duplicated in the same level, the appending will be failed, too.
	 *
	 * @param levels an array that contains each level keys as a path
	 * @param key an item key. Must be unique in the same level
	 * @param data a data
	 * @throws IllegalArgumentException if the path is invalid, the key is invalid or duplicated in the same level
	 */
	public void append(String[] levels, String key, T data) {
		checkLevels(levels);
		checkKey(key);

		NavigationNode<T> base = findNode(levels);
		if (base == null)
			throw new IllegalArgumentException("the path is invalid");
		if (hasDuplicatedKeyInLevel(base, key))
			throw new IllegalArgumentException("the key is duplicated in the same level");

		NavigationNode<T> newNode = new NavigationNode<T>(key, data);
		NavigationNode<T> right = base.getRight();
		// Left
		newNode.setLeft(base);
		base.setRight(newNode);
		// Right
		if (right != null) {
			newNode.setRight(right);
			right.setLeft(newNode);
		}

		notifyChange(levels.length, NavigationEvent.Type.ADD, newNode);
	}

	/**
	 * Inserts the data before the specific path.
	 * If the path is invalid, the insertion will be failed.
	 * If the key is duplicated in the same level, the insertion will be failed, too.
	 *
	 * @param path a path string. Will be separated into levels by {@code /} symbols. Like {@code A/B/C}
	 * @param key an item key. Must be unique in the same level
	 * @param data a data
	 * @throws IllegalArgumentException if the path is invalid, the key is invalid or duplicated in the same level
	 */
	public void insertBefore(String path, String key, T data) {
		checkPath(path);
		insertBefore(path.split("/"), key, data);
	}

	/**
	 * Inserts the data before the item of specific levels.
	 * If the path is invalid, the insertion will be failed.
	 * If the key is duplicated in the same level, the insertion will be failed, too.
	 *
	 * @param levels an array that contains each level keys as a path
	 * @param key an item key. Must be unique in the same level
	 * @param data a data
	 * @throws IllegalArgumentException if the path is invalid, the key is invalid or duplicated in the same level
	 */
	public void insertBefore(String[] levels, String key, T data) {
		checkLevels(levels);
		checkKey(key);

		NavigationNode<T> base = findNode(levels);
		if (base == null)
			throw new IllegalArgumentException("the path is invalid");
		if (hasDuplicatedKeyInLevel(base, key))
			throw new IllegalArgumentException("the key is duplicated in the same level");

		NavigationNode<T> newNode = new NavigationNode<T>(key, data);
		NavigationNode<T> left = base.getLeft();
		NavigationNode<T> parent = base.getParent();
		// Left
		if (left != null) {
			left.setRight(newNode);
			newNode.setLeft(left);
		}
		// Right
		newNode.setRight(base);
		base.setLeft(newNode);
		// Parent
		base.setParent(null);
		if (parent != null) {
			parent.setChild(newNode);
			newNode.setParent(parent);
		}

		notifyChange(levels.length, NavigationEvent.Type.ADD, newNode);
	}

	private NavigationNode<T> findNode(String[] levels) {
		NavigationNode<T> base = _root.getChild();
		for (int i = 0, len = levels.length; i < len; i++) {
			if (base == null) break;

			String key = levels[i];
			checkKey(key);
			boolean isFound = false;

			while (true) {
				if (base.getKey().equals(key)) {
					isFound = true;
					break;
				}
				if (base.getRight() == null) break;
				base = base.getRight();
			}

			if (!isFound) break;
			if (i + 1 == len) return base;
			base = base.getChild();
		}
		return null;
	}

	private boolean hasDuplicatedKeyInLevel(NavigationNode<T> node, String key) {
		if (node == null)
			return false;

		NavigationNode<T> n = node;
		while (n != null) {
			if (n.getKey().equals(key)) return true;
			n = n.getRight();
		}
		n = node.getLeft();
		while (n != null) {
			if (n.getKey().equals(key)) return true;
			n = n.getLeft();
		}
		return false;
	}

	/**
	 * Removes the data associated with the specific path.
	 * If the path is navigated currently, the navigation of that level will be changed to the sibling (right or left).
	 *
	 * @param path a path string. Will be separated into levels by {@code /} symbols. Like {@code A/B/C}
	 * @return the previous data.
	 * @throws IllegalArgumentException the path is invalid
	 */
	public T remove(String path) {
		checkPath(path);
		return remove(path.split("/"));
	}

	/**
	 * Removes the data associated with the specific levels.
	 * If the path is navigated currently, the navigation of that level will be changed to the sibling (right or left).
	 *
	 * @param levels an array that contains each level keys as a path
	 * @return the previous data.
	 * @throws IllegalArgumentException the path is invalid
	 */
	public T remove(String[] levels) {
		checkLevels(levels);

		NavigationNode<T> node = findNode(levels);
		if (node == null)
			throw new IllegalArgumentException("the path is invalid");

		return removeNode(node, levels.length);
	}

	private T removeNode(NavigationNode<T> node, int level) {
		NavigationNode<T> parent = node.getParent();
		NavigationNode<T> left = node.getLeft();
		NavigationNode<T> right = node.getRight();
		T value = node.getValue();
		if (right != null) {
			right.setLeft(left);
			right.setParent(parent);
		}
		if (left != null) left.setRight(right);
		if (parent != null) parent.setChild(right);

		node.setParent(null);
		node.setLeft(null);
		node.setRight(null);
		node.setValue(null);

		NavigationLevel<T> navLevel = getNavigationLevel(level);
		if (navLevel != null) {
			if (((DefaultNavigationLevel) navLevel).getNode() == node) {
				if (right != null)
					((DefaultNavigationLevel<T>) navLevel).setNode(right);
				else if (left != null)
					((DefaultNavigationLevel<T>) navLevel).setNode(left);
				notify(NavigationEvent.Type.NAVIGATE, navLevel, ((DefaultNavigationLevel<T>) navLevel).getNode(), "current");
			}
			notify(NavigationEvent.Type.REMOVE, navLevel, node, "itemIterator", "items");
		} else { // If the last item was removed in that level, the level is null. So notify the parent level instead.
			int parentLevel = level - 1;
			if (parentLevel > 0) {
				NavigationLevel<T> pLevel = getNavigationLevel(parentLevel);
				notify(NavigationEvent.Type.REMOVE, pLevel, node, "child", "childItems");
			}
		}
		return value;
	}

	private NavigationLevel<T> getNavigationLevel(int level) {
		NavigationLevel<T> navLevel = getRoot();
		for (int l = 1; l < level; l++) {
			if (navLevel == null) break;
			navLevel = navLevel.getChild();
		}
		return navLevel;
	}

	/**
	 * A shortcut of {@link #getRoot()}.{@code navigateTo(String)}.
	 *
	 * @param key the item key
	 */
	public void navigateTo(String key) {
		getRoot().navigateTo(key);
	}

	/**
	 * Gets the first navigation level.
	 *
	 * @return the first navigation level
	 */
	public NavigationLevel<T> getRoot() {
		if (_firstLevel.getNode() == null) {
			NavigationNode<T> child = _root.getChild();
			_firstLevel.setNode(child);
			_firstLevel.setHead(child);
			_firstLevel.setNavigated(true); // Special case for first level
		}
		return _firstLevel;
	}

	/**
	 * Navigates to the specified path.
	 * If the key could not be found in the specific level, the action will not be performed.
	 *
	 * @param path a path string. Will be separated into levels by {@code /} symbols. Like {@code A/B/C}
	 * @throws IllegalArgumentException if the path is invalid
	 */
	public void navigateToByPath(String path) {
		checkPath(path);
		navigateToByPath(path.split("/"));
	}

	/**
	 * Navigates to the specified levels.
	 * If the key could not be found in the specific level, the action will not be performed.
	 *
	 * @param levels an array that contains each level keys as a path
	 * @throws IllegalArgumentException if the path is invalid
	 */
	public void navigateToByPath(String[] levels) {
		checkLevels(levels);
		if (findNode(levels) == null)
			throw new IllegalArgumentException("the path is invalid");

		NavigationLevel<T> level = getRoot();
		for (String key : levels) {
			if (level == null) break;

			level.navigateTo(key);
			level = level.getChild();
		}
	}

	/**
	 * Adds a listener to the list that's notified each time a change
	 * to the model occurs.
	 *
	 * @param listener Listener object
	 */
	public void addEventListener(EventListener<NavigationEvent<T>> listener) {
		if (listener == null)
			throw new NullPointerException();
		_listeners.add(listener);
	}

	/**
	 * Removes a listener from the list that's notified each time
	 * a change to the model occurs.
	 *
	 * @param listener Listener object
	 */
	public void removeEventListener(EventListener<NavigationEvent<T>> listener) {
		_listeners.remove(listener);
	}

	protected void fireEvent(NavigationLevel<T> level, NavigationEvent.Type type, NavigationNode<T> node) {
		final NavigationEvent<T> evt = new NavigationEvent<T>(
			this, level, type, node.getKey(), node.getValue()
		);
		for (EventListener<NavigationEvent<T>> l : _listeners) {
			try {
				l.onEvent(evt);
			} catch (Exception e) {
				log.warn("Failed to fire event", e);
			}
		}
	}

	// Serializable//
	private synchronized void writeObject(java.io.ObjectOutputStream s) throws java.io.IOException {
		s.defaultWriteObject();
		Serializables.smartWrite(s, _listeners);
	}

	private void readObject(java.io.ObjectInputStream s) throws java.io.IOException, ClassNotFoundException {
		s.defaultReadObject();
		_listeners = new ArrayList<EventListener<NavigationEvent<T>>>();
		Serializables.smartRead(s, _listeners);
	}
}
